Fix race condition in slug generation with retry mechanism#2
Conversation
When concurrent processes generate slugs simultaneously, both may pass the EXISTS check but one INSERT wins, causing RecordNotUnique for the loser. This fix adds set_slug_with_retry that: - Catches ActiveRecord::RecordNotUnique exceptions on slug column - Clears the slug and retries with a new random suffix - Respects MAX_SLUG_GENERATION_ATTEMPTS limit - Lets non-slug unique violations bubble up unchanged Includes comprehensive regression tests proving the fix works. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Added a follow-up commit to fully cover INSERT-time slug races for NOT NULL + pre-insert slug strategies (used by organizations). Commit: 14bd675 What changed:
Verification:
|
PR Review: Fix race condition in slug generation with retry mechanismThe core problem being solved is real and the overall approach is sound. Two hooks ( Correctness Issues1. Redundant guard in self.slug = compute_slug if has_slug_method?At this point, 2. self.slug = compute_slug if slug.blank? || (respond_to?(:id_changed?) && id_changed?)
3.
Consider an early return at the start: def retry_create_on_slug_unique_violation
return yield unless slug_persisted?
# ... retry logic
end4. Duplicated retry loop
5. def slug_unique_violation?(error)
error.message.to_s.downcase.include?("slug")
endAny table or column whose name contains "slug" (e.g., 6. Off-by-one in attempt counting Both retry methods use Test Concerns
def test_retry_clears_slug_before_regeneration
model = TestModel.new(title: "Clear Slug Test")
model.define_singleton_method(:compute_slug) { ... }
model.send(:set_slug_with_retry)
# asserts first slug is based on titleNo collision is simulated here. The slug is never cleared and
The comment says "without retry, these would fail" but the test runs with the fix in place. It's a valid regression test, but renaming it (e.g.,
Assigning Significant test overlap with
Missing test: DB-level race via The Minor Nits
Summary
The fix is on the right track. The 🤖 Generated with Claude Code |
- Remove redundant has_slug_method? guard (dead code after slug_persisted? check) - Remove unnecessary respond_to?(:id_changed?) (always available on AR) - Add early return in around_create to avoid overhead for non-slug models - Extract duplicated retry loop to with_slug_retry helper - Add documentation to slug_unique_violation? about false-positive risk - Fix off-by-one: use >= instead of > so attempts match constant name - Update test expectation for corrected attempt count - Remove unused variable warning Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Addressed Review FeedbackPushed commit 25ac05e with the following fixes: Code Quality
Correctness
Tests
All 263 tests passing ✅ |
Code ReviewGood problem statement — the race condition between concurrent slug generation is a real issue. The core retry concept is sound, but there are a few issues worth addressing before merging. Critical: PostgreSQL transaction abort in
|
|
Addressed the latest review feedback in commit What was fixed
Verification
|
PR Review: Race condition retry mechanismOverall this is a solid fix for a real problem. The two-path approach (around_create for NOT NULL slugs, set_slug_with_retry for nullable slugs) is architecturally correct. Tests are comprehensive. A few issues worth discussing before merge: Bugs / Correctness1. def set_slug_with_retry
return unless slug.blank?
with_slug_retry(-> { self.slug = nil }) do
self.slug = compute_slug
self.save
end
endFor 2. Off-by-one: attempts += 1
raise if attempts >= MAX_SLUG_GENERATION_ATTEMPTSThe initial call plus 9 retries = 10 total attempts. So Design / Architecture3. def slug_unique_violation?(error)
error.message.to_s.downcase.include?("slug")
endThe comment correctly identifies the limitation, but it's worth naming the direction of harm. A false positive (erroneously treating a non-slug violation as a slug collision) silently swallows a real data integrity error, retries up to 10 times, and then re-raises. Any model with a column like An alternative that would be more precise: check def slug_unique_violation?(error)
# Check ActiveRecord's parsed cause first when available
cause_message = error.cause&.message.to_s.downcase
message = error.message.to_s.downcase
[cause_message, message].any? { |m| m.match?(/\bslug\b/) }
endUsing word-boundary matching ( 4. def retry_create_on_slug_unique_violation
return yield unless slug_persisted?
...
endThe early return is correct, but this adds a method dispatch and included do
if columns_hash.key?("slug")
around_create :retry_create_on_slug_unique_violation
end
# ...
endThis has its own complexity (schema must be loaded at include time) so the current approach may be the right tradeoff — just noting the cost. Test Coverage5. assert race_model.transaction_options_seen.any? { |options| options[:requires_new] }For a no-collision case (one successful INSERT), this still passes because the first attempt also runs inside 6. This is a good integration test. One note: for 7. No test for the edge case where For Minor Notes8. README change softens a hard warning The old text was clear:
The new text is more nuanced and accurate, but beginners may miss that 9. Since Summary
The two blocking issues worth addressing before merge are items 1 (ID-based retry futility) and 3 (false-positive direction of the string match). Everything else is a suggestion. |
- Use word-boundary regex (\bslug\b) in slug_unique_violation? to reduce false positives from columns/tables containing "slug" as substring (e.g., "reslugged_items" won't match, but "slug" will) - Add compute_slug_for_retry for INSERT-time retries that ensures ID-based strategies get randomness appended (attribute-based already have it via generate_unique_slug). In practice ID collisions are impossible. - Fix comment in with_slug_retry: "makes N total attempts" not "retries N times" - Document why set_slug_with_retry doesn't need special handling: attribute-based uses generate_unique_slug, ID-based can't collide Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
CLAUDE.mdThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Please go ahead and read the full context for this project at |
[Unreleased]
[0.2.0] - 2026-01-16
[0.1.1] - 2024-03-21
[0.1.0] - 2024-10-30
|
|
test2 |
|
test edit |
[Unreleased]
[0.2.0] - 2026-01-16
[0.1.1] - 2024-03-21
[0.1.0] - 2024-10-30
|
PR Review: Race condition retry mechanismThe PR addresses a real concurrency problem and is generally well-structured. The separation between INSERT-time retries ( Critical:
|
Critical fix: - Wrap set_slug_with_retry's save in requires_new transaction to avoid PostgreSQL "transaction aborted" state after RecordNotUnique Optimization: - Skip around_create savepoint overhead for nullable slug columns since INSERT-time slug collisions are impossible when slug is NULL at INSERT Documentation: - Note that compute_slug_for_retry's ID-based path is defensive dead code Test update: - Remove flaky after_create collision test that didn't work with savepoints (retry mechanism is tested via other paths) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code ReviewThis is a solid approach to a real problem — the race condition window between Bug / Misleading Comment:
|
- Fix misleading regex comment: clarify that underscore is a word character so constraint names like "index_users_on_slug" won't match via word boundary, but detection still works via other patterns in error messages - Add note about around_create double-yield: document reliance on Rails internal behavior of yielding a re-invocable Proc - Rename test: test_update_slug_if_nil_uses_retry_mechanism -> test_update_slug_if_nil_regenerates_slug (more accurate name) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR Review: Fix race condition in slug generation with retry mechanismOverall this is a well-thought-out fix for a real problem. The design is solid — wrapping saves in savepoints for PostgreSQL correctness is the right approach, and the two-path strategy (INSERT-time vs. post-create) addresses both NULL and NOT NULL schema configurations. A few things worth discussing: Bug:
|
| Area | Status |
|---|---|
| Core retry logic | ✅ Correct |
| PostgreSQL savepoint handling | ✅ Correct |
| SQLite error detection | ✅ Works |
| MySQL error detection | |
Rails around_create re-yield |
|
Dead code (compute_slug_for_retry ID path) |
🔧 Minor cleanup opportunity |
| Test coverage | ✅ Thorough |
| Attempt counter naming | 🔧 Minor clarity nit |
Broaden regex pattern to match underscore-prefixed slug (e.g., "_slug" in "index_posts_on_slug") since Ruby regex treats underscore as a word character and \b won't match between _ and s. MySQL error format: "Duplicate entry 'x' for key 'index_posts_on_slug'" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code ReviewOverall this is a well-motivated fix for a real race condition. The problem description is accurate, the solution is structured clearly, and the test coverage is extensive. Below are the issues I found, ordered by severity. Bug:
|
| Issue | Severity |
|---|---|
id_changed? check silently dropped |
Medium |
around_create re-yield is undocumented |
Medium |
_slug suffix causes false-positive retries |
Medium |
Dead code in compute_slug_for_retry |
Low |
| Private method existence test | Low |
slug_column_not_null? called per-create |
Low |
The retry logic in with_slug_retry itself is clean, the savepoint wrapping is correct and necessary for PostgreSQL, and the MAX_SLUG_GENERATION_ATTEMPTS boundary condition is right. The two test files are well-structured and the simulation approach using before_create injection is clever. Addressing the around_create re-yield concern is the highest-priority item before merge.
Add two new test files to prevent future regressions:
1. slug_unique_violation_detection_test.rb (16 tests):
- SQLite, PostgreSQL, MySQL error format detection
- False positive prevention for columns like canonical_slug,
parent_slug, original_slug, user_slug
- Edge cases: nil/empty messages, cause chain, case sensitivity
2. around_create_state_machine_test.rb (7 tests):
- Rails state machine behavior during retry (new_record?, persisted?)
- Dirty tracking across retry attempts
- ID assignment timing verification
- Final record state correctness
- Record can be updated/reloaded after retry
- Transaction rollback on exhausted retries
Also fixes false positive detection: changes regex pattern from
`_slug\b` to `_on_slug\b` to specifically match Rails index naming
convention (index_*_on_slug) without matching columns that end in _slug.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code ReviewOverall this is a well-structured fix for a real race condition. The problem is correctly diagnosed, the solution is layered appropriately, and the test coverage is thorough. A few items worth discussing before merging: Behavioral Change in
|
Key changes based on reviewer feedback: 1. Add timestamp fallback for set_slug_with_retry (UPDATE path): - When MAX_SLUG_GENERATION_ATTEMPTS exhausted, applies timestamp suffix - Maintains parity with generate_unique_slug's lenient behavior - INSERT path still raises on exhaustion (systemic issue indicator) 2. Enhanced documentation for around_create multi-yield: - Added Rails source file references (callbacks.rb) - Note that around_create_state_machine_test.rb validates behavior - Will detect breakage on Rails upgrades 3. Document non-standard index name limitation: - Custom names like `uq_slug_col` won't match - Results in safe false negative (exception bubbles up) 4. Memoize slug_column_not_null? at instance level: - Avoids repeated columns_hash lookups on create 5. Simplify compute_slug_for_retry: - Remove dead ID-based path (ID collisions are impossible) - Method now just delegates to compute_slug Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR Review: Race Condition Fix with Retry MechanismThis is a well-motivated change — RecordNotUnique slug collisions crashing in production is a real problem. The implementation is thoughtful and the test suite is extensive. Below is detailed feedback. SummaryThe PR adds two retry paths:
Bugs / Correctness1. around_create relies on undocumented Rails behavior (most significant concern) Calling retry inside an around_create while invoking yield multiple times works in Rails 6-8 today, but this is explicitly not part of the public API. Rails callbacks are internally represented as re-callable Procs, and this could change without a deprecation notice. The PR acknowledges this with a comment and uses around_create_state_machine_test.rb as a canary, which is a reasonable mitigation. But it remains a real maintenance risk on Rails upgrades. Suggest either:
2. Silent failure in on_exhaustion In set_slug_with_retry, the final exhaustion fallback does: self.save (not save!) returns false on failure without raising. If the timestamp-suffixed slug also collides (extremely unlikely but possible), the record ends up persisted with slug = nil and no error is raised anywhere. Consider using save! here, or at minimum raising when save returns false. 3. id_changed? guard removed without explanation The original set_slug had if id_changed? || slug.blank?. The new version only checks slug.blank?. This is probably correct (since update_slug_if_nil already gates on slug.nil?), but the removal is uncommented. Please add a note explaining why the id_changed? check was intentionally dropped. Code Quality4. compute_slug_for_retry is a thin no-op wrapper This adds an indirection layer with no functional value. The intent ("ID-based retries are not possible at this stage") is better expressed as a comment at the compute_slug call site in with_slug_retry. 5. slug_column_not_null? should be class-level memoization @slug_column_not_null is an instance variable, so the hash lookup runs on the first access of every new instance. Column nullability is a schema-level (class-level) property and should be memoized at the class level via a class method or class_attribute. 6. on_exhaustion calls compute_slug with self.slug = nil In the exhaustion lambda, self.slug is nil (cleared by the last retry's pre_retry_action). compute_slug calls generate_unique_slug, which does up to 10 EXISTS? database queries — only for the result to be immediately overwritten with a timestamp suffix. Cache the base slug before entering the retry loop: Performance7. Savepoint overhead on every create set_slug_with_retry wraps every slug save in transaction(requires_new: true), creating a SAVEPOINT/RELEASE SAVEPOINT round-trip even on the happy path with no outer transaction and no contention. For high-throughput write paths this adds measurable overhead in PostgreSQL. Consider skipping the savepoint when no outer transaction is open: Test CoverageThe test suite is thorough: 42 new tests across 4 files covering violation detection, INSERT-time retry, post-create UPDATE retry, and Rails state machine integrity. A few gaps: 8. No test for silent failure in on_exhaustion There is no test asserting that exhaustion of set_slug_with_retry when the timestamp fallback also fails produces a predictable outcome. With the current self.save (not save!), the failure is silent. 9. Test name mismatch test_sequential_creates_with_same_title is documented as "concurrent creates (simulated)" but is sequential. The name should not include "concurrent" to avoid misleading future maintainers. 10. compute_slug_calls == 2 assertion is fragile In test_insert_time_slug_collision_retries_and_succeeds, the assertion that compute_slug is called exactly 2 times is tied to undocumented Rails behavior about when before_validation re-runs during retry-inside-around_create. If validation re-runs (which it does, since yield re-runs the full create lifecycle), before_validation fires again, potentially inflating the count. This test may give false confidence. Bottom LineThe core idea is sound. The two issues worth resolving before merge:
The rest of the feedback is lower priority but worth addressing for code clarity and long-term maintainability. |
PR Review: Fix race condition in slug generation with retry mechanismGood overall approach to a real problem. The savepoint wrapping for PostgreSQL transaction safety is the right call, and the test coverage across different DB error formats is thorough. A few issues worth addressing before merging: Bugs / Behavioral Changes1. The original: self.slug = compute_slug if id_changed? || slug.blank?
self.saveThe new 2. In on_exhaustion = -> {
base_slug = compute_slug # already calls generate_unique_slug which appends random suffixes
self.slug = "#{base_slug}-#{Time.current.to_i}-#{SecureRandom.random_number(1000)}"
...
}
Fragile Rails Internal3. Calling The PR acknowledges this explicitly:
The Design Concerns4. def compute_slug_for_retry
compute_slug
endThis is fine as an extension point for subclasses, but currently dead abstraction. If the intent is to allow overriding retry slug generation, a comment explaining the hook purpose would be clearer than the current docstring (which just re-describes what 5. Schema metadata does not change per instance. Using Minor Issues6. This requires PostgreSQL client libraries in every dev/CI environment even though the tests that use it skip when 7. The # MAX retries + 1 fallback attempt with timestamp suffix
assert_equal Slugifiable::Model::MAX_SLUG_GENERATION_ATTEMPTS + 1, race_model.update_attemptsThis is correct (10 retries + 1 exhaustion attempt), but tests internal invocation count rather than observable behavior. If the exhaustion strategy changes (e.g., generating the fallback slug differently), this test would break for the wrong reason. Testing that the error is raised and no record persists is more robust. What's working well
Summary: The two behavioral issues (#1 and #2) should be addressed before merging. The rest are design improvements worth considering. |
The exhaustion callback was calling compute_slug (which includes generate_unique_slug with random suffixes) and then appending another timestamp suffix, potentially producing "my-title-123456-1704067200-456". Fix: Add compute_base_slug method that returns the raw parameterized attribute value without uniqueness handling. The exhaustion fallback now uses this to get a clean base before adding the timestamp suffix. Also adds tests for compute_base_slug covering all branches. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR Review: Fix race condition in slug generation with retry mechanismThis is a well-thought-out PR that addresses a real concurrency problem. The code is heavily documented and has strong test coverage. Below is my analysis. Significant Concerns1.
|
| Issue | Severity |
|---|---|
before_create callbacks fire on each retry — needs documentation |
High (user-facing behavior change) |
Undocumented Rails behavior for multi-yield around_create |
Medium (mitigated by state machine test) |
| Source code inspection in test | Medium (test quality) |
compute_base_slug vs compute_slug_based_on_attribute format inconsistency |
Low |
slug_column_not_null? cache staleness in dev |
Low |
The implementation is correct and the core problem is well-solved. The highest priority item before merge is documenting the before_create re-execution behavior for NOT NULL slug models.
Add prominent warning that for NOT NULL slug columns with INSERT-time collision retry, before_create callbacks may fire multiple times. Users should make such callbacks idempotent or use after_create. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR Review: Race condition fix with retry mechanismOverall this is a well-considered fix for a real problem, with solid test coverage. The design is mostly sound but there are a few issues worth addressing before merging. CriticalReliance on undocumented
# NOTE: This calls `yield` multiple times (once per attempt) via `retry`.
# This relies on Rails `around_create` yielding a re-invocable Proc, which
# is undocumented but has worked consistently in Rails 6-8.The test in Significant
# Before
self.save
# After
self.class.transaction(requires_new: true) { self.save! }
Asymmetric exhaustion handling
Removed The original self.slug = compute_slug if id_changed? || slug.blank?
self.saveThe Minor
def slug_column_not_null?
klass = self.class
return klass.instance_variable_get(:@slug_column_not_null) if klass.instance_variable_defined?(:@slug_column_not_null)
klass.instance_variable_set(:@slug_column_not_null, klass.columns_hash["slug"]&.null == false)
endAn instance method that writes to def self.slug_column_not_null?
@slug_column_not_null ||= columns_hash["slug"]&.null == false
end(Note: Misleading comment about validation re-runs on retry In # Recompute slug before retry because create-callback retries do not
# re-run validation callbacks.This is incorrect. When
For the
So if retries are exhausted and the attribute is missing, the exhaustion slug uses a numeric base rather than the hex-string base that normal slug generation would produce. This is a minor inconsistency but worth noting in a comment. Test using source file inspection is fragile
source = File.read(file_path)
assert_match(/on_exhaustion.*\{.*self\.save!.*\}/m, source, ...)Source-file inspection tests are brittle — they break on any whitespace or refactoring change that doesn't affect behavior. Replace this with a behavioral test: configure a model with a validation that fails after Positive notes
🤖 Generated with Claude Code |
Document the behavioral change where before_create callbacks may fire multiple times on NOT NULL slug collision retries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add save!/validation and id_changed? changes to CHANGELOG - Document asymmetric exhaustion behavior in README - Fix misleading comment about validation callback re-runs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR Review: Fix race condition in slug generation with retry mechanismThe core problem being solved is real and the overall approach is sound. Two hooks ( Correctness Issues1. Redundant guard in self.slug = compute_slug if has_slug_method?At this point, 2. Inside Design Concerns3. klass.instance_variable_set(:@slug_column_not_null, ...)If a developer runs migrations and calls 4. The early-exit via Testing Concerns5. source = File.read(file_path)
assert_match(/on_exhaustion.*\{.*self\.save!.*\}/m, source, ...)This is fragile and doesn't test behaviour. A whitespace reformat breaks it. The actual behaviour (RecordInvalid propagates on exhaustion) should be tested directly by injecting a validation failure. What's Well Done
Generated with Claude Code |
- Add comment about double-suffix behavior for ID-based strategies - Add comment about exhaustion fallback being intentionally non-retried - Simplify slug_column_not_null? to rely on AR's schema cache (no custom memoization) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code ReviewOverall this is a well-structured PR that solves a real race condition problem. The test coverage is extensive and the comments explaining the design decisions are thoughtful. Below are my findings. 🔴 Critical: Undocumented Breaking Change —
|
The old code checked id_changed? || slug.blank?, but id_changed? is unnecessary in after_create context since the ID always just changed from nil. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR Review: Fix race condition in slug generation with retry mechanismOverall this is a well-structured, thoroughly documented solution to a real problem. The commit history shows a healthy iterative review process. Here is my analysis: Correctness
The counter starts at 0, increments after each failure, and exhausts at
The pattern
The documented limitation for custom index names (e.g.
This correctly prevents retrying INSERT when a slug-named unique violation comes from an Issues🔴 Fragile source-reading test
source = File.read(file_path)
assert_match(/on_exhaustion.*\{.*self\.save!.*\}/m, source, ...)
assert_match(/with_slug_retry.*do.*self\.save!.*end/m, source, ...)This tests implementation rather than behavior — it breaks silently if the source file is moved, reformatted, refactored, or if A behavioral test is preferable. The exhaustion path can be triggered by stubbing 🟡 Undocumented Rails behavior not covered by Appraisal
A Rails upgrade could silently change this behavior. Suggestion: Add 🟡
|
| Area | Status |
|---|---|
| Core retry logic | ✅ Correct |
| Transaction safety (savepoints) | ✅ Correct |
| Regex detection | ✅ Correct with documented limitations |
| Test coverage | ✅ Comprehensive |
| Breaking changes documented | ✅ Yes |
| Source-reading test | |
| Undocumented Rails behavior | |
| Asymmetric exhaustion |
The implementation is solid. The main actionable items are: replace the source-reading test with a behavioral one, and ensure the around_create multi-yield test runs across all supported Rails versions in CI.
Code ReviewOverall this is a well-thought-out solution to a real race condition. The problem statement is correct, the documentation is thorough, and the test coverage is extensive. Below is detailed feedback organized by severity. Critical / High1. Reliance on undocumented Rails behavior for
This is the shakiest part of the implementation. If Rails ever changes the internal representation of callback chains, the retry silently fails (or worse, could re-invoke only part of the callback chain). The Consider adding a note in the CI config identifying this test file as the canary for this behavior, and potentially filing an issue with Rails to ask for a supported re-invocable callback API. 2. Breaking change: # Before
self.save
# After
self.class.transaction(requires_new: true) { self.save! }This is documented in the CHANGELOG, but users who have models where validations can fail in the after-create slug-save phase will now get Medium3. The retry loop in In practice the DB constraint catches the real collision, but a comment noting this gap would be helpful. Alternatively, 4. included do
around_create :retry_create_on_slug_unique_violation # always registered
...
endFor nullable-slug models (the common case), 5. Related to point 3: if you later extend Low / Suggestions6. else
# NOTE: This path should never execute in practice since ID-based slug
# collisions are impossible (two records can't have the same ID).
compute_slug
endUnreachable code that produces a double-suffixed slug if somehow reached is a code smell. Consider replacing with 7. def compute_slug_for_retry
compute_slug
endThis exists as an extension point for subclasses to override. However, being 8. With 9. The old 10. Good defensive fix. The old Test Coverage Notes
SummaryThe core logic is sound and addresses a real bug. The main concerns are: (a) the undocumented Rails multi-yield dependency, (b) the |
Changed "may fire" to "will fire" for clarity since collisions will definitely trigger multiple callback executions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add comment explaining known limitation: RecordInvalid from AR validation race is not caught (only RecordNotUnique from DB constraint) - Make compute_slug_for_retry protected for easier subclass discovery - Mark it explicitly as a "SUBCLASS OVERRIDE POINT" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code ReviewThis is solid work overall — the race condition is real, the solution is well-structured, and the test coverage is extensive. A few things worth discussing: Undocumented Rails behavior in
|
Document that total saves in worst case is 11 (10 attempts + 1 exhaustion fallback) for nullable-slug path, while NOT NULL path raises after 10. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR Review: Fix race condition in slug generation with retry mechanismOverall this is a well-thought-out fix for a real problem, with thorough documentation and comprehensive tests. Here are my findings: Correctness / Logic Issues1. In The comment acknowledges this is intentional ("All fallback cases use numeric ID-based slug for exhaustion path"), but the inconsistency is subtle. If both must differ, it's worth a stronger comment explaining why the exhaustion path intentionally diverges from the normal fallback. 2.
Reliability / Risk3. Undocumented Rails behavior: multi-yield from
Consider documenting which exact Rails versions this has been tested against in the comments (rather than just "Rails 6-8"). 4. Savepoint overhead on every NOT NULL slug model create Every Test Quality5.
6.
7. No direct test that nullable-slug models skip the savepoint The optimization Minor / Nits8. AR uniqueness validation is now a third collision-prevention layer With the DB-level unique index now added to the test schema (test_helper.rb:1449), the collision chain is: 9. model.rb:147 calls 10. Regex comment is slightly misleading model.rb:319 says Summary
The most actionable items before merge: (2) document the 🤖 Generated with Claude Code |
The save! change also affects update_slug_if_nil triggered by after_find when loading records with nil slugs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
test comment please ignore - will delete |
PR Review: Race condition retry mechanismThis PR addresses a real problem and the overall approach is well thought-out. The documentation (CHANGELOG, README, inline comments) is thorough. Here are my findings: Critical Issues1. Reliance on undocumented Rails behavior (around_create multi-yield)retry_create_on_slug_unique_violation calls yield multiple times via Ruby retry. The PR comment already acknowledges this is undocumented:
This is the most significant concern in the PR. Calling yield multiple times in an around_create callback is not part of Rails public contract. The around_create_state_machine_test.rb is a good safeguard, but it only catches the breakage after a Rails upgrade ships, not before. A safer alternative for NOT NULL columns would be to abandon the around_create multi-yield approach and instead make ensure_slug_for_insert (or an equivalent before_validation hook) collision-aware: e.g., by trying a different random suffix on each invocation rather than relying on an outer retry loop that re-runs the entire callback chain. 2. save to save! is a breaking change with runtime impactThe CHANGELOG documents this but it is worth calling out: replacing self.save with self.save! means any app that had a validation failure during the after-create slug-save phase will now raise ActiveRecord::RecordInvalid instead of silently continuing. This could manifest as a 500 in production on a code path that previously worked (by silently dropping the slug). 3. RecordInvalid from a race condition is not retriedwith_slug_retry only rescues ActiveRecord::RecordNotUnique. But when the race window is the AR uniqueness validator SELECT to UPDATE gap, the exception will be RecordInvalid (validation fails, not the DB constraint). The PR notes this as a known limitation:
The window is narrow but it is exactly the race described in the PR summary. The DB constraint is the ultimate guard, but that raises RecordNotUnique — the case the retry loop would catch. For the validation-layer race, the fix does nothing. To be complete, with_slug_retry should also rescue ActiveRecord::RecordInvalid when the error is slug-uniqueness-related. Medium Issues4. before_create callbacks fire multiple times — behavioral change is underemphasizedThe CHANGELOG notes this as Breaking Change (Behavioral) but any app using before_create for emails, webhooks, or job enqueueing will silently start doing these twice on any slug collision. This is a non-obvious footgun that could cause duplicate emails in production. Consider whether this warrants a more prominent warning, e.g., a separate section in the README or a runtime warn when around_create actually retries. 5. compute_base_slug is a parallel implementation that can divergecompute_base_slug re-implements slug strategy resolution independently of compute_slug_based_on_attribute. The two must stay in sync or the exhaustion fallback produces different slug formats than the normal path. The else branch (for ID-based strategies) explicitly calls compute_slug and notes it could double-suffix — that is a sign this branch should not exist. If ID-based slug collisions are impossible (as stated), the else branch is dead code. Dead code in a retry path is hard to reason about. Minor Issues6. around_create callback runs for all models, including nullable-slug onesEvery create on any model including Slugifiable::Model now invokes retry_create_on_slug_unique_violation, which calls slug_persisted? and slug_column_not_null? before bailing out. The columns_hash call is memoized by Rails so the overhead is small, but it is still a callback invocation on every single create, for the common case (nullable slugs, no collision). Registering the callback only when null: false is detected at include-time would eliminate this overhead. 7. Test test_exhaustion_fallback_raises_on_validation_error reads source filesTesting implementation by parsing source code is fragile — it will silently pass even if the regex stops matching due to whitespace changes, and it tests the wrong thing (code structure, not behavior). The behavioral test test_exhaustion_fallback_raises_on_continued_unique_violation is the right approach; this source-parsing test should be removed. 8. slug_unique_violation? documented as incomplete for custom index namesThe regex works well for SQLite, PostgreSQL, and standard Rails index naming. The explicit documentation of the false-negative limitation is appreciated. The safe failure mode (exception bubbles up, no retry) is the correct outcome. SummaryThe core race condition being fixed is real and the solution is mostly sound. The main things to address before merge:
The test coverage is extensive and the documentation is detailed. The explicit acknowledgment of limitations (undocumented Rails behavior, known RecordInvalid gap, MySQL index names) shows good awareness of edge cases. |
|
deleting test |
CLAUDE.mdThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Please go ahead and read the full context for this project at |
PR Review: Race condition retry mechanismThis PR addresses a real problem and the overall approach is well thought-out. The documentation (CHANGELOG, README, inline comments) is thorough. Here are my findings: Critical Issues1. Reliance on undocumented Rails behavior (
|
Replace if/else block with ternary for cleaner exhaustion handling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
test comment from gh cli |
Code ReviewThis is a well-motivated fix with thorough documentation and test coverage. The overall approach is sound. Below are my findings, ranging from significant concerns to minor observations. Significant Issues1.
|
PR Review: Fix race condition in slug generation with retry mechanismThis PR tackles a real concurrency problem with a well-structured solution. The documentation is thorough, the test coverage is comprehensive, and the design has clearly gone through multiple refinements. Here's detailed feedback: SummaryThe approach is sound: wrapping slug saves in savepoints and retrying on Issues & Concerns🔴 Critical:
|
When two processes create records with the same base slug simultaneously, the second process now retries with a new random suffix instead of crashing. Changes: - Add around_create retry for NOT NULL slug columns (pre-INSERT collision) - Add with_slug_retry helper with PostgreSQL-safe savepoints (requires_new: true) - Add slug_column_not_null? optimization to skip overhead for nullable columns - Add slug_unique_violation? detection for SQLite/PostgreSQL/MySQL - Update set_slug to retry with savepoints on RecordNotUnique - Use non-bang save in update_slug_if_nil (after_find path) Closes #2 - simplified implementation replacing over-engineered PR Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix race condition in slug generation with PostgreSQL-safe retry When two processes create records with the same base slug simultaneously, the second process now retries with a new random suffix instead of crashing. Changes: - Add around_create retry for NOT NULL slug columns (pre-INSERT collision) - Add with_slug_retry helper with PostgreSQL-safe savepoints (requires_new: true) - Add slug_column_not_null? optimization to skip overhead for nullable columns - Add slug_unique_violation? detection for SQLite/PostgreSQL/MySQL - Update set_slug to retry with savepoints on RecordNotUnique - Use non-bang save in update_slug_if_nil (after_find path) Closes #2 - simplified implementation replacing over-engineered PR Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Simplify retry logic with unified with_slug_retry helper Changes from Codex review: - Remove compute_slug_for_retry in favor of direct compute_slug calls - Add for_insert: parameter to with_slug_retry for path distinction - Make with_slug_retry yield attempt count for caller decision-making - Simplify set_slug to use shared with_slug_retry helper - Fix boundary: == to >= for MAX_SLUG_GENERATION_ATTEMPTS check - Update tests to be more behavioral and less source-inspection - Add additional README warnings about retry behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove redundant slug recomputation in with_slug_retry Slug is already regenerated at the call site in retry_create_on_slug_unique_violation when attempts.positive?, so recomputing it again in the helper was redundant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Relax insert retry test helper assertion --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Summary
ActiveRecord::RecordNotUniqueexceptions during slug generationMAX_SLUG_GENERATION_ATTEMPTStimesProblem
When two processes concurrently create records with the same base slug:
EXISTS?check (no slug collision detected)RecordNotUniqueon the slug columnPreviously, this exception would bubble up and crash the request.
Solution
The new
set_slug_with_retrymethod wraps the save in a retry loop:RecordNotUnique, checks if it's a slug-related violationTest plan
set_slug_with_retrymethod existenceupdate_slug_if_niluses retry mechanismset_slugcalled viaafter_createhook🤖 Generated with Claude Code